Upgrade to Egglog 3, add extraction controls, and retain Param-Eq demos - #414
Upgrade to Egglog 3, add extraction controls, and retain Param-Eq demos#414saulshanabrook wants to merge 6 commits into
Conversation
Merging this PR will improve performance by 36.48%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | test_jit[lda] |
7.4 s | 5.3 s | +37.83% |
| ⚡ | WallTime | test_jit[lda] |
7.9 s | 5.8 s | +35.15% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing sr (1e81090) with main (6b2016e)
| def test_init(self): | ||
| class B(Expr): | ||
| def __init__(self, value: i64Like) -> None: | ||
| return B.wrap(value) # type: ignore[return-value] # noqa: PLE0101 - symbolic constructor body |
Migrate the bindings to Egglog 3, consolidate the general API and correctness fixes with tests and changelog coverage, and preserve Param-Eq as a reusable module and CLI, bounded CI stress cases, and an optional aggregate-only external harness. Pin the Egglog v3 compatibility fix and experimental primitives to immutable revisions, patching the core workspace source so direct and transitive dependencies share one Rust type identity.
| @method(preserve=True) | ||
| def pick_key(self) -> T: | ||
| runtime_self = to_runtime_expr(self) | ||
| key_type, _value_type = runtime_self.__egg_typed_expr__.tp.args | ||
| maybe_type = RuntimeClass( | ||
| Thunk.value(Declarations.create(runtime_self, cast("HasDeclarations", Maybe))), | ||
| TypeRefWithVars(Ident.builtin("Maybe"), (key_type.to_var(),)), | ||
| _egg_has_params=True, | ||
| ) | ||
| initial = cast("Maybe[T]", maybe_type.none()) | ||
| return map_fold_kv( | ||
| lambda picked, key, _value: picked.match(lambda _: picked, cast("Maybe[T]", maybe_type.some(key))), | ||
| initial, | ||
| self, | ||
| ).unwrap() | ||
|
|
||
| @method(preserve=True) | ||
| def keys(self) -> Set[T]: | ||
| runtime_self = to_runtime_expr(self) | ||
| key_type, _value_type = runtime_self.__egg_typed_expr__.tp.args | ||
| set_type = RuntimeClass( | ||
| Thunk.value(Declarations.create(runtime_self, cast("HasDeclarations", Set))), | ||
| TypeRefWithVars(Ident.builtin("Set"), (key_type.to_var(),)), | ||
| _egg_has_params=True, | ||
| ) | ||
| return map_fold_kv(lambda keys, key, _value: keys.insert(key), cast("Set[T]", set_type.empty()), self) |
There was a problem hiding this comment.
These should be normal expressions, not sure why they are like this... if they aren't builtins then we shouldnt include them here, instead they should just be inlined where we need them... as normal expression, we shouldnt use theprivate runtime expression APIs in public places like this
| def map_filter_kv(f: Callable[[T, V], Unit], xs: Map[T, V]) -> Map[T, V]: | ||
| runtime_xs = to_runtime_expr(xs) | ||
| map_type = RuntimeClass( | ||
| Thunk.value(Declarations.create(runtime_xs, cast("HasDeclarations", Map))), | ||
| runtime_xs.__egg_typed_expr__.tp.to_var(), | ||
| _egg_has_params=True, | ||
| ) | ||
| return map_fold_kv( | ||
| lambda result, key, value: catch(lambda: f(key, value)).match(lambda _: result.insert(key, value), result), | ||
| cast("Map[T, V]", map_type.empty()), | ||
| xs, | ||
| ) | ||
|
|
||
|
|
||
| def map_map_values(f: Callable[[T, V], V2], xs: Map[T, V]) -> Map[T, V2]: | ||
| runtime_xs = to_runtime_expr(xs) | ||
| key_type, value_type = runtime_xs.__egg_typed_expr__.tp.args | ||
| probe_decls = runtime_xs.__egg_decls__.copy() | ||
| dummy_key = RuntimeExpr.__from_values__(probe_decls, TypedExprDecl(key_type, DummyDecl())) | ||
| dummy_value = RuntimeExpr.__from_values__(probe_decls, TypedExprDecl(value_type, DummyDecl())) | ||
| with set_current_ruleset(None): | ||
| transformed = cast("Callable[[RuntimeExpr, RuntimeExpr], object]", f)(dummy_key, dummy_value) | ||
| if not isinstance(transformed, RuntimeExpr): | ||
| raise TypeError(f"Map value transform must return an egglog expression, got {type(transformed)}") | ||
| output_type = transformed.__egg_typed_expr__.tp | ||
| map_type = RuntimeClass( | ||
| Thunk.value(Declarations.create(runtime_xs, transformed, cast("HasDeclarations", Map))), | ||
| TypeRefWithVars(Ident.builtin("Map"), (key_type.to_var(), output_type.to_var())), | ||
| _egg_has_params=True, | ||
| ) | ||
| return map_fold_kv( | ||
| lambda result, key, value: result.insert(key, f(key, value)), | ||
| cast("Map[T, V2]", map_type.empty()), | ||
| xs, | ||
| ) | ||
|
|
||
|
|
||
| def map_merge_with(f: Callable[[V, V], V], left: Map[T, V], right: Map[T, V]) -> Map[T, V]: | ||
| return map_fold_kv( | ||
| lambda result, key, value: catch(lambda: result[key]).match( | ||
| lambda old: result.insert(key, f(old, value)), result.insert(key, value) | ||
| ), | ||
| left, | ||
| right, | ||
| ) | ||
|
|
||
|
|
There was a problem hiding this comment.
these also shouldnt be here if they are not primitives... they should just be normal expressions where they are used
| cost_callables: set[CallableRef] = field(default_factory=set) | ||
| # Cache of top-level expressions lowered with any available synthetic let | ||
| # references. Rules and rewrites must never read from this cache. | ||
| expr_to_let_egg_cache: dict[ExprDecl, bindings._Expr] = field(default_factory=dict) |
There was a problem hiding this comment.
Why is this different from expr_to_egg_cache? Are both required?
| # Use constructor declaration instead of constant b/c constants cannot be extracted | ||
| # https://github.com/egraphs-good/egglog/issues/334 |
There was a problem hiding this comment.
Constants dont exist anymore
| from .builtins import ExprValueError # noqa: PLC0415 - avoid a module import cycle | ||
| from .runtime import RuntimeExpr # noqa: PLC0415 - avoid a module import cycle | ||
|
|
||
| if tp.ident == Ident.builtin("Map"): | ||
| runtime_expr = RuntimeExpr.__from_values__(self.decls, decl) | ||
| try: | ||
| as_dict = cast("Map[BaseExpr, BaseExpr]", runtime_expr).value | ||
| except ExprValueError: | ||
| return self(expr, unwrap_lit=unwrap_lit, ruleset_ident=ruleset_ident, parens=parens), "expr" | ||
| if unwrap_lit: | ||
| items = ", ".join( | ||
| f"{self(cast('RuntimeExpr', k).__egg_typed_expr__, unwrap_lit=True)}: {self(cast('RuntimeExpr', v).__egg_typed_expr__, unwrap_lit=True)}" | ||
| for k, v in as_dict.items() | ||
| ) | ||
| return f"{{{items}}}", "Map" | ||
| map_str = f"{tp}.empty()" | ||
| for key, value in as_dict.items(): |
There was a problem hiding this comment.
We should not use the runtime to rebuild things here... why is this neccessary? Doesnt have to be canoncial, just print expressions as they are without getting value
| assert '(let $__expr_0 (LetConflictNum_var "explicit"))' in egglog_string | ||
| assert '(let $__expr_1 (LetConflictNum_var "synthetic"))' in egglog_string |
There was a problem hiding this comment.
In these tests dont test for the exact string, this seems birttle... also all tests in here when they can should avoid using low level _ APIs and instead just use high level APIs that actually test behavior and not the low level behavior. remove any tests that aren't needed if we just are testing high level behavior
| def test_anonymous_combined_rulesets_use_deterministic_generated_names() -> None: | ||
| first = ruleset(name="combined_name_probe_first") | ||
| second = ruleset(name="combined_name_probe_second") | ||
| combined = unstable_combine_rulesets(first, second) | ||
| egraph = EGraph(save_egglog_string=True) |
There was a problem hiding this comment.
like this one too, make sure the tests are verifying a user seeable output, not low level implementaiton details
| ## Numeric Predicates and Exact Rationals | ||
|
|
||
| The `f64.is_finite()` method returns a `Unit` fact when its value is neither | ||
| infinite nor NaN. This makes it suitable for guarding rules that evaluate | ||
| partial floating-point operations. | ||
|
|
||
| The experimental exact `Rational` sort accepts `fractions.Fraction` and | ||
| `i64Like` values in arithmetic, reflected arithmetic, powers, `min`/`max`, and | ||
| ordering predicates. `RationalLike` is the corresponding public type alias. |
There was a problem hiding this comment.
this seems too specific for this level of doc
| mutate_egraph.register(x) | ||
| mutate_egraph.run(10) | ||
| mutate_egraph.check(eq(x).to(Int(10) + Int(1))) | ||
| incremented = mutate_egraph.let("incremented", x) |
There was a problem hiding this comment.
why is the run removed here
| Dynamic row costs live in a canonical table named | ||
| `cost_table_<egg-function-name>`. If a compatible, bodyless raw function with | ||
| the same input sorts and `i64` output already has that name when the cost table | ||
| is created, it is reused. An incompatible callable already occupying the name, | ||
| or an incompatible overload that would map to the same canonical table, raises | ||
| an error instead of making the cost table use a generated suffix, because the | ||
| backend only consults the canonical name. Ordinary generated-name collision | ||
| handling still applies to callables registered after the cost table. |
There was a problem hiding this comment.
this is like mostly low level details, lets not get into this as much
|
|
||
| At the low-level bindings layer, the experimental `multi-extract` command | ||
| returns one {class}`egglog.bindings.UserDefinedOutput`. | ||
| {meth}`egglog.bindings.UserDefinedCommandOutput.as_multi_extract` returns a | ||
| {class}`egglog.bindings.MultiExtractOutput` whose `termdag` stores the shared | ||
| term DAG and whose `terms` groups the variant term IDs in root order. It | ||
| returns `None` for a different user-defined output. The high-level method | ||
| performs this conversion automatically. |
There was a problem hiding this comment.
this is also too low level, we shouldnt talk about bindings here
| Configure worker threads per e-graph with `num_threads`. The default of `1` | ||
| keeps execution serial; `0` uses the machine's available parallelism. You can | ||
| change the setting later with `set_num_threads` and inspect it with | ||
| `num_threads`. The bindings no longer read `RAYON_NUM_THREADS`. |
There was a problem hiding this comment.
dont reference old env variable
| _CallableMode: TypeAlias = Literal["function", "constructor", "eager", "rewrite"] | ||
|
|
||
|
|
||
| def _normalize_callable_mode( # noqa: C901, PLR0911, PLR0912 |
There was a problem hiding this comment.
can you see if this could be reduced in loc somehow? its kinda verbose and hard to follow
| callback_context = _COST_MODEL_CALLBACK_VALUES.get() | ||
| in_cost_model_callback = callback_context is not None and callback_context[0] is self | ||
| if in_cost_model_callback: | ||
| ref = typed_expr.expr.callable | ||
| table_is_registered = ( | ||
| ref in self._state.callable_ref_to_egg_fn | ||
| if isinstance(typed_expr.expr, CallDecl) | ||
| else ref in self._state.cost_table_names | ||
| ) | ||
| if not table_is_registered: | ||
| msg = "Tables queried by cost-model callbacks must be registered before extraction starts" | ||
| raise ValueError(msg) |
There was a problem hiding this comment.
this global seems messy why is it needed
| """ | ||
|
|
||
| marginal_cost: Callable[[EGraph, BaseExpr], DAG_COST] | ||
| identity: DAG_COST |
There was a problem hiding this comment.
do we really need identity?
| # already propagating, do not replace it with a cleanup failure. | ||
| try: | ||
| call_with_current_trace(self._state.egraph.run_program, bindings.Pop(span(1), 1)) | ||
| except BaseException: |
| "after_params": report.extracted_params, | ||
| }) | ||
| connection.send(payload) | ||
| except BaseException: # worker errors are accounted for without publishing private input text |
| payload = {"status": watched.status} | ||
| elif process.returncode == 0: | ||
| payload = _parse_haskell_output(stdout) | ||
| except (OSError, ValueError): |
Summary
Upgrade the Rust extension and Python API to Egglog 3, expose its newer runtime and extraction controls, and preserve the paused Param-Eq work as a documented experimental module with bounded CI demonstrations.
Runtime and API changes
Map.rebuild(),Set.rebuild(), andVec.rebuild(); Egglog 3 rebuilds container values internally.EGraph(num_threads=...)rather thanRAYON_NUM_THREADS.Pair,Maybe,catch, map folding, map/set lengths, additional numeric conversions and guardedf64operations, and an expanded experimentalRationalAPI.reverse_argscallables and isolate higher-order callable probing from the live ruleset.RunReport.can_stop, safenaiveand opt-inunsafe-seminaiverule evaluation, and per-e-graph/per-rule decomposition controls.(fail ...); failures that cannot be replayed safely invalidate the transcript.PyObjectprimitives on worker threads back to the caller.var()typing for parameterized expression types.Maybe,catch, and map folding intentionally remain unsupported in proof mode.Extraction and costs
extractor="tree" | "greedy-dag"to extraction APIs.extract_multiple, consuming one structured experimental result with a sharedTermDagand one variant group per root.keep_bestfor table-backed constructors, relations, bodyless functions, and bodyless constants.set_costtables consistently across extraction, multi-root extraction, and compaction. Negative costs fail when written rather than panicking later.TreeCostModel, retainingCostModelas an alias, and add additive marginalDagCostModelvalues usable by tree or greedy-DAG extraction.GreedyDagCost,GreedyDagCostModel, andgreedy_dag_cost_model; useDagCostModel(..., extractor="greedy-dag").Custom Python cost models remain single-root. A general total tree-cost callback cannot be adapted to greedy-DAG extraction;
DagCostModelsupplies the required marginal costs.Param-Eq handoff
egglog.exp.param_eqretains the binary and container representations, restricted expression parser, extraction cost, schedules, CLI, and three project-authored end-to-end examples. Normal pytest/CI runs both representations, parses each extracted result, and compares it with the source at finite sample points. The binary repeated-monomial case remains an explicitly reported iteration-limit stress boundary.experiments/param_eqcontains the optional private-corpus runner, resource guards, provenance validation, and restart notes. Private expressions and external archives are not redistributed. No result CSVs are checked in while the work is paused; any future publication path must generate and validate aggregate-only outputs.The retained simplifier targets inputs where the source and introduced subexpressions are defined. Its finite sample checks are regression evidence, not a universal equivalence proof; general nonzero/definedness analysis remains future research work.
Dependency stack
1eea60a14f214505741d22bdd6c9b501b21c04eee0a20ce67bbfedf4ace91f4235293165dbb098bcCargo patches the canonical Egglog source so direct bindings and the experimental dependency use the same core revision and Rust type identity.
Validation
uv sync --reinstall-package egglog --all-extras --lockeduv run pytest --benchmark-disable -q- 1,113 passed, 1 skipped, 4 xfailedmake mypymake stubtestuv run ruff check .uv run ruff format --check .uv lock --checkcargo fmt --checkcargo check --locked --all-targetscargo test --locked --lib- 4 passedcargo clippy --locked --all-targetsmake docsThe current head is
1e81090581dafce13351b16af404d746dba017d0.